Skip to content

[orchestrator-v2] fix(mobile): Stabilize iOS draft attachments, Hermes sort, and Home#3923

Open
mwolson wants to merge 37 commits into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:fix/ios-v2-attachment-persistence
Open

[orchestrator-v2] fix(mobile): Stabilize iOS draft attachments, Hermes sort, and Home#3923
mwolson wants to merge 37 commits into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:fix/ios-v2-attachment-persistence

Conversation

@mwolson

@mwolson mwolson commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Keep CTM mobile draft image attachments working when they carry both a local id and a dataUrl.
  • Avoid a Hermes fatal in the queue UI by replacing missing Array.prototype.toSorted with a shared copy-sort helper.
  • Hide orchestration-v2 subagent threads from the mobile Home list (forks stay visible).
  • Stabilize Home native-stack options and callbacks so ordinary Home updates do not re-enter navigation through PreventRemoveProvider.
  • Normalize V2 shell active/archive membership from archivedAt so archived threads do not stick on the Home list after a missed archive delta.

Base

This branch is now built on mwolson/t3code v2-base, a merge of the shared iOS/mobile fixes line (ios-fixes-main) into t3code/codex-turn-mapping. Because GitHub diffs against t3code/codex-turn-mapping, the PR diff includes that merge (mobile keyboard/feed fixes, shell reconnect heal, serverConfig/vcsRefs caching, and the V2 thread resume completion marker) in addition to the two commits listed below. Review the two head commits for this PR's own delta.

Problem and Fix

Problem and Why it Happened Fix
CTM drafts can include image attachments with both a local id and a dataUrl. The start-turn path did not treat those dual-tagged attachments as uploads, so order and persistence broke for mobile screenshots. Treat dual-tagged attachments as uploads, strip local-only fields before persistence, and dispatch server IDs while preserving order with already-persisted attachments.
Hermes 250829098 (RN 0.85.3) does not implement Array.prototype.toSorted. CTM queue UI calls it from deriveThreadQueueWorkflowState / ThreadQueueControl, so queuing a reply or finishing a turn can crash with TypeError: undefined is not a function. Add @t3tools/shared/Array copySorted ([...values].sort) and use it on mobile-reachable call sites (threadWorkflows, ThreadRelationshipsBanner, use-thread-selection, CTM model.ts).
Orchestration-v2 subagent shells appeared in the mobile Home list, unlike the desktop sidebar. Skip threads with lineage.relationshipToParent === "subagent" when grouping Home rows; keep fork threads.
Unstable Home native-stack options and callbacks re-entered setOptions and could hit maximum update depth through PreventRemoveProvider on CTM device launches. Memoize iOS Home header options (filter menu inputs as deps; handlers via ref) and stabilize Settings / New Task callbacks and title option objects.
A dropped thread.archived shell delta (or a mis-tagged full snapshot) could keep an archived thread listed as active on Home even after the reconnect heal applied a fresh snapshot. Add normalizeShellThreadMembership and apply it to every full snapshot (reducer enrichment refreshes and reconnect heals) so active/archive membership always derives from archivedAt. The HTTP heal, forced socket snapshot, and stale-snapshot rejection it pairs with live in v2-base.

Validation

  • Targeted tests: client-runtime attachment command tests; Home subagent filter tests (including fork negative control); shared Array tests; shell-sync / shellReducer membership tests.
  • vp check (0 errors) and full vp run typecheck on the rebuilt branch.
  • Device: archived threads from a paired CTM environment leave Home after reconnect/heal.
  • Device: Release Hermes com.mwolson.t3code.v2.dev from local orchestrator-v2 integration; queued reply send after active turn finished without crash; Home list usable after nav stability fix.

Notes

Note

Stabilize iOS draft attachments, fix Hermes sort compatibility, and add Android home screen UI

  • Fixes iOS T3MarkdownTextShadowNode to derive layout content from current children rather than stale cached state, preventing incorrect attributed strings and attachment ranges during layout.
  • Replaces Array.prototype.toSorted calls (not available in Hermes) with a new copySorted helper from packages/shared/src/Array.ts throughout the codebase.
  • Adds Android Home screen with a custom header (AndroidHomeHeader), floating action button (AndroidHomeFab), and persisted collapsed project group state via mobilePreferencesAtom.
  • Introduces a full Android terminal rendering stack: JNI bridge via GhosttyBridge, canvas-based TerminalCanvasView, and snapshot-driven T3TerminalView backed by vendored libghostty-vt.
  • Adds crash logging infrastructure: installCrashLogger writes bounded crash records and breadcrumbs to disk on fatal errors; native iOS handlers are injected via the withIosCrashLog Expo plugin.
  • Introduces Effect-based persistence layer (MobileDatabase, MobileSecureStorage, MobileStorage, MobilePreferencesStore, MobileEnvironmentCacheStore) replacing ad-hoc file/SecureStore access.
  • Adds server config and VCS refs caching in both mobile SQLite and web IndexedDB stores, with offline fallback in the listRefs atom and shell/thread state.
  • Adds Android-specific in-screen headers, custom anchored menus (AndroidAnchoredMenu), cross-platform SF Symbol mapping (AppSymbol), and syntax-highlighted markdown code blocks.
  • Introduces launcher quick actions (useAppShortcuts) that persist and reflect recently opened threads on Android.
  • Risk: connectionStorageLayer no longer provides EnvironmentCacheStore; callers that depended on it must now use the new persistence layer composition in apps/mobile/src/persistence/layer.ts.

Macroscope summarized 30b5502.


Note

High Risk
Large native surface area (vendored Ghostty VT, new Android renderers, entitlements/signing paths, and crash hooks) increases regression risk on mobile builds and local iOS signing workflows.

Overview
This diff is a broad mobile native and tooling pass rather than the orchestrator-v2 attachment/Hermes/Home fixes described in the PR text (those paths are not in this diff).

iOS local development adds T3CODE_IOS_PERSONAL_TEAM / T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID, validates the bundle id in app.config.ts, gates widgets, Sign in with Apple (Clerk), and widget assets behind a Personal Team plugin that strips push, app groups, and Apple Sign In entitlements. README documents ios:release and Personal Team env vars.

Expo config and UX registers DM Sans at the native layer (aligned with global.css family names), turns on Android predictive back with compat plugins, adds expo-sqlite, expo-quick-actions, Gradle heap / modern dialog / menu plugins, bumps EAS build NODE_OPTIONS heap, and blocks Metro from watching .t3/.

Crash persistence loads installCrashLog before the app graph, exposes writeSyncText / installFatalHandler on T3NativeControls, and adds T3CrashLog plus an iOS crash-log config plugin for fsynced Documents/crash-logs.

Android parity for custom Expo modules ships Kotlin implementations for composer (controlled document, token chips, paste images), header buttons, and a large review diff canvas (async JSON decode, scroll, sticky file headers, syntax tokens). iOS gets a markdown layout fix so shadow-node state is rebuilt from children on every layout, plus optional fillWidth on selectable markdown.

Terminal on Android vendors libghostty-vt (headers + rebuild script/docs) so the same JS terminal contract can use Ghostty VT on Android; iOS composer fonts move to DMSans-Regular / DMSans-Medium.

Smaller touches: Effect service convention docs for dependency acquisition and runtime boundaries; marketing Ctrl+Enter shortcut label on non-Mac; desktop electron-builder patch bump; root app.json stub.

Reviewed by Cursor Bugbot for commit 30b5502. Bugbot is set up for automated code reviews on this repo. Configure here.

juliusmarminge and others added 17 commits July 7, 2026 15:55
…gg#3781)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
…pingdotgg#3795)

Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
…tgg#3823)

Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Horus Lugo <horusgoul@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: maria-rcks <maria@kuuro.net>
Co-authored-by: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com>
Co-authored-by: Ben Davis <45952064+bmdavis419@users.noreply.github.com>
Co-authored-by: Alex <me@pixp.cc>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: codex <codex@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 01caa900-9681-45ae-b735-8e918c5c908f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jul 12, 2026
Comment thread apps/mobile/src/features/home/HomeHeader.tsx
@mwolson
mwolson marked this pull request as ready for review July 12, 2026 21:35
Comment thread packages/shared/src/Array.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

16 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

@mwolson
mwolson force-pushed the fix/ios-v2-attachment-persistence branch from 2857eb3 to 76a88a0 Compare July 12, 2026 21:41
@mwolson mwolson changed the title fix(mobile): Stabilize CTM iOS draft attachments, Hermes sort, and Home [orchestrator-v2] fix(mobile): Stabilize CTM iOS draft attachments, Hermes sort, and Home Jul 12, 2026
mwolson added 2 commits July 12, 2026 18:53
Capture RCTFatal message and stack to Documents via a native handler so
Release Hermes aborts leave last-crash.json without a device console.
Harden the JS ErrorUtils path with a minimal write-first record, durable
fsync writes, breadcrumb max-wait flush, and an Expo AppDelegate plugin.

Skip NativeStackScreenOptions setOptions when content is unchanged so
Thread catch-up re-renders do not loop through PreventRemoveProvider and
hit maximum update depth.
CI Mobile Native Static Analysis failed on a multi-line if where the
opening brace was on the following line.
Warm-cache afterSequence resume can skip a dropped thread.archived
delta after later events advance snapshotSequence, leaving archived
threads on the home list. Always HTTP-heal (or force a full socket
snapshot) before resuming live shell deltas, and reject stale full
snapshots from the stream.
Comment thread packages/client-runtime/src/state/shell.ts Outdated
When HTTP shell heal fails, the full socket snapshot is the membership
source of truth. Accept that first snapshot even if its sequence is behind
the warm disk cache so ghost active threads cannot stick.
@mwolson
mwolson force-pushed the fix/ios-v2-attachment-persistence branch from 33a612d to 10513f1 Compare July 13, 2026 02:19
Comment thread packages/client-runtime/src/state/shell.ts
Comment thread packages/client-runtime/src/state/shell.ts Outdated
A prior session can arm acceptNextSocketSnapshotAuthoritatively when HTTP
heal fails and then disconnect before the socket snapshot arrives. Clear
the flag on a successful HTTP heal so a later reconnect does not treat
stale enrichment snapshots as authoritative.
@mwolson
mwolson force-pushed the fix/ios-v2-attachment-persistence branch from 10513f1 to a5467d3 Compare July 13, 2026 02:27
@mwolson mwolson changed the title [orchestrator-v2] fix(mobile): Stabilize CTM iOS draft attachments, Hermes sort, and Home [orchestrator-v2] fix(mobile): Stabilize iOS draft attachments, Hermes sort, and Home Jul 13, 2026
mwolson and others added 11 commits July 12, 2026 22:40
waitForJsonLogMatch only yieldNow'd between attempts, so under busy CI it
could return before the ACP mock agent wrote session/cancel. Use a real
wall-clock delay (not TestClock sleep) and fail explicitly on timeout.
CI Check treats setTimeout as an Effect lint error. Keep wall-clock delays
via a diagnostics-scoped helper, fail with a tagged timeout, and avoid
TestClock-bound Effect.sleep so ACP mock log polls still work under load.
Move the header options memo before the missing environmentId, threadId,
or selectedThread guards so hook order stays stable if those become null
on a re-render of a mounted ThreadRouteContent instance.
## Summary

- Add a synchronized stream item after a resumed thread subscription has attached its live buffer.
- Keep a warm cached thread synchronizing through reconnect until that item arrives.

## Notes

Squash-merge of twistedgrim's PR onto ios-fixes-main. Follow-up gate commit will land separately with capability negotiation and marker-after-catch-up ordering.
Emit the completion marker only on subscribeThread when clients opt in,
after catch-up replay, and keep warm threads synchronizing until it
arrives. Advertise support via ServerConfig so old servers still use the
ready fallback and old clients never see an unknown stream kind.
Do not paint a warm disk shell snapshot before the HTTP or socket heal.
Stale active membership was flashing a larger Home list, then shrinking
when the authoritative heal arrived. Disk remains offline/heal fallback.
Keep Home empty while session is still None during online connect setup;
only apply disk fallback from true disconnect. Persist markerMode so
thread resume re-arms awaitingCompletion after ordinary reconnects.
…Android

Anchor the chat scroll position to the end of the scroll range when the
keyboard opens with the list resting at the bottom, reading the live
composer padding per frame. This survives the event-ordering race where
composer expansion lands before the keyboard's onStart (quick tap after
opening a thread), which zeroed out the previous delta-based
compensation and left the last message hidden behind the input. Also
re-assert the settled scroll target after the keyboard animation ends
and guard that correction with shouldShiftContent.
…-base

Cut B0 for the merge-based orchestrator-v2 build model: an append-only
merge of ios-fixes-main (924ad10) and upstream/t3code/codex-turn-mapping
(c14c908). Notable resolutions:

- Adopt the V2 orchestration surface (client-runtime threads/shell state,
  server orchestration-v2) while preserving v1 fixes: shell reconnect and
  status logic, serverConfig/vcsRefs caching, the mobile storage refactor,
  and the mobile thread UI layer (AppSymbol mappings, ThreadFeed refs).
- Port the full thread resume completion marker (requestCompletionMarker
  plus synchronized stream item) onto the V2 contracts, server, and ws
  surfaces; drop the dead v1-only marker schema since CTM removed the v1
  subscribe path.
- Restore expectedBranch on ThreadMetaUpdateCommand (live consumers in
  the server decider and web GitActionsControl).
- Adapt web and mobile environment caches to the shared V2 cache schema
  (ORCHESTRATION_CACHE_SCHEMA_VERSION, projection.thread.id keys,
  decode-or-discard on corrupt records).
- Regenerate pnpm-lock.yaml from CTM's side with the keyboard patch
  intact.
Persist dual-tagged draft image attachments, replace Hermes-missing toSorted
with copySorted on mobile-reachable paths, hide subagent threads from Home,
and stabilize Home native-stack options to avoid PreventRemove max-update-depth.
Derive active/archive membership from archivedAt when applying full shell
snapshots (reducer enrichment refreshes and reconnect heals) so a dropped
archive delta cannot stick an archived thread on the home list. The HTTP
heal, forced socket snapshot, and stale-snapshot rejection this pairs with
live in v2-base.
@mwolson
mwolson force-pushed the fix/ios-v2-attachment-persistence branch from a5467d3 to 0a2efa9 Compare July 14, 2026 03:13
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 14, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0a2efa9. Configure here.

} else {
Typeface.DEFAULT
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Android composer ignores font family

Medium Severity

setFontFamily only switches between Typeface.MONOSPACE and Typeface.DEFAULT. Names like DMSans-Regular from app.config.ts never load, so the Android composer keeps the system default while the rest of the app uses DM Sans.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0a2efa9. Configure here.

Comment thread app.json
@@ -0,0 +1,3 @@
{
"expo": {}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty root Expo config committed

Low Severity

A new repo-root app.json with an empty "expo": {} object was added. Mobile already configures Expo via apps/mobile/app.config.ts, and the PR notes call out excluding local scaffolding, so this looks like accidental tooling output that can confuse monorepo Expo resolution.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0a2efa9. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High

if (nextSnapshot === null) {

applyItem checks hasServerBackedSnapshot and current.snapshot at the top of the fromDisk path but the check is not atomic with the state update at the bottom. If setDisconnected starts applyDiskFallback concurrently with an HTTP heal, the disk fiber can pass the guard, yield, then resume after the HTTP fiber installs its authoritative snapshot — and overwrite it with the stale cached snapshot, reintroducing archived threads that the heal removed. The fromDisk guard needs to be rechecked immediately before committing the disk snapshot (or serialized with the state update) so a concurrent authoritative heal wins.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/shell.ts around line 138:

`applyItem` checks `hasServerBackedSnapshot` and `current.snapshot` at the top of the `fromDisk` path but the check is not atomic with the state update at the bottom. If `setDisconnected` starts `applyDiskFallback` concurrently with an HTTP heal, the disk fiber can pass the guard, yield, then resume after the HTTP fiber installs its authoritative snapshot — and overwrite it with the stale cached snapshot, reintroducing archived threads that the heal removed. The `fromDisk` guard needs to be rechecked immediately before committing the disk snapshot (or serialized with the state update) so a concurrent authoritative heal wins.

Comment on lines +52 to +59
Events(
"onComposerChange",
"onComposerSelectionChange",
"onComposerFocus",
"onComposerBlur",
"onComposerPasteImages",
"onComposerContentSizeChange",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium t3composereditor/T3ComposerEditorModule.kt:52

Android hardware-key submit does not work: T3ComposerEditorModule omits the onComposerSubmit event and T3ComposerEditorView has no hardware-key handler, so pressing the documented submit shortcut on Android never emits a submit event or invokes handleSend, unlike iOS which emits onComposerSubmit for Command-Return. Register an onComposerSubmit event in the Events(...) list and add an OnKeyListener (or equivalent) on the editor that emits it for the Android submit shortcut.

      Events(
        "onComposerChange",
        "onComposerSelectionChange",
        "onComposerFocus",
        "onComposerBlur",
        "onComposerPasteImages",
+        "onComposerSubmit",
        "onComposerContentSizeChange",
      )
Also found in 1 other location(s)

apps/mobile/src/native/T3ComposerEditor.native.tsx:87

ComposerEditor does not destructure or forward the public onSubmit callback to the Android native view. Existing callers such as ThreadComposer pass onSubmit={handleSend}, so the documented hardware-keyboard submit shortcut is silently ignored on Android, unlike the iOS implementation which forwards onComposerSubmit={onSubmit}.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt around lines 52-59:

Android hardware-key submit does not work: `T3ComposerEditorModule` omits the `onComposerSubmit` event and `T3ComposerEditorView` has no hardware-key handler, so pressing the documented submit shortcut on Android never emits a submit event or invokes `handleSend`, unlike iOS which emits `onComposerSubmit` for Command-Return. Register an `onComposerSubmit` event in the `Events(...)` list and add an `OnKeyListener` (or equivalent) on the editor that emits it for the Android submit shortcut.

Also found in 1 other location(s):
- apps/mobile/src/native/T3ComposerEditor.native.tsx:87 -- `ComposerEditor` does not destructure or forward the public `onSubmit` callback to the Android native view. Existing callers such as `ThreadComposer` pass `onSubmit={handleSend}`, so the documented hardware-keyboard submit shortcut is silently ignored on Android, unlike the iOS implementation which forwards `onComposerSubmit={onSubmit}`.

Effect.forkScoped,
);

yield* Effect.addFinalizer(() =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium state/server.ts:172

The finalizer at line 172 saves the pending config, but it runs before the persistence worker fiber is interrupted because Effect finalizers execute in reverse registration order. When the scope closes, the finalizer can write config B while the worker is still writing an older config A; if the worker's write finishes after the finalizer's, it overwrites the cache with stale data. Join or stop the worker before flushing in the finalizer, or serialize both paths through the same lane.

Also found in 1 other location(s)

apps/mobile/src/persistence/mobile-storage.ts:167

saveConnection performs an unprotected read-modify-write of the entire CONNECTIONS_KEY value (and clearSavedConnection does the same). If two connection saves, or a save and clear, run concurrently, both can read the same old list and the last write silently discards the other operation, losing a saved connection or resurrecting a cleared one. These operations need to share a lock around the complete read/write transaction.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/server.ts around line 172:

The finalizer at line 172 saves the pending config, but it runs *before* the persistence worker fiber is interrupted because Effect finalizers execute in reverse registration order. When the scope closes, the finalizer can write config B while the worker is still writing an older config A; if the worker's write finishes after the finalizer's, it overwrites the cache with stale data. Join or stop the worker before flushing in the finalizer, or serialize both paths through the same lane.

Also found in 1 other location(s):
- apps/mobile/src/persistence/mobile-storage.ts:167 -- `saveConnection` performs an unprotected read-modify-write of the entire `CONNECTIONS_KEY` value (and `clearSavedConnection` does the same). If two connection saves, or a save and clear, run concurrently, both can read the same old list and the last write silently discards the other operation, losing a saved connection or resurrecting a cleared one. These operations need to share a lock around the complete read/write transaction.

(materialName ? ANDROID_ICON_BY_MATERIAL_NAME[materialName] : undefined) ??
(sfSymbol ? ANDROID_ICON_BY_SF_SYMBOL[sfSymbol] : undefined);

if (!AndroidIcon) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/AppSymbol.tsx:190

SymbolView renders nothing on Android for SF symbol names that are missing from ANDROID_ICON_BY_SF_SYMBOL, and callers pass no fallback. Several reachable call sites use names that are omitted from the map — for example arrow.down on the queue move-down button, plus list.number, hourglass, arrow.counterclockwise, and arrow.up.right.square — so those icons disappear on Android, including an actionable queue control. The fallback branch at line 190–192 returns props.fallback ?? null, which is null for these callers. Add the missing SF-symbol-to-Tabler mappings for all call sites (or supply a generic fallback) so every symbol used through the wrapper has an Android glyph.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/components/AppSymbol.tsx around line 190:

`SymbolView` renders nothing on Android for SF symbol names that are missing from `ANDROID_ICON_BY_SF_SYMBOL`, and callers pass no `fallback`. Several reachable call sites use names that are omitted from the map — for example `arrow.down` on the queue move-down button, plus `list.number`, `hourglass`, `arrow.counterclockwise`, and `arrow.up.right.square` — so those icons disappear on Android, including an actionable queue control. The fallback branch at line 190–192 returns `props.fallback ?? null`, which is `null` for these callers. Add the missing SF-symbol-to-Tabler mappings for all call sites (or supply a generic fallback) so every symbol used through the wrapper has an Android glyph.

private var horizontalOffset = 0
private var lastVisibleRange: Pair<Int, Int>? = null

var rows: List<DiffRow> = emptyList()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium t3reviewdiff/T3ReviewDiffView.kt:583

When rows is replaced with a new list that occupies the same index range as the current viewport (e.g. a same-sized diff at offset 0), onVisibleRowsChanged is not emitted for the new content. The rows setter calls rebuildOffsets() but never clears lastVisibleRange, so emitVisibleRange() still sees the old (first, last) pair, treats it as already-emitted, and suppresses the callback. Consider resetting lastVisibleRange = null in the rows setter so the next invalidate() re-emits the visible range for the new content.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt around line 583:

When `rows` is replaced with a new list that occupies the same index range as the current viewport (e.g. a same-sized diff at offset 0), `onVisibleRowsChanged` is not emitted for the new content. The `rows` setter calls `rebuildOffsets()` but never clears `lastVisibleRange`, so `emitVisibleRange()` still sees the old `(first, last)` pair, treats it as already-emitted, and suppresses the callback. Consider resetting `lastVisibleRange = null` in the `rows` setter so the next `invalidate()` re-emits the visible range for the new content.

parsed?.connections ?? [],
Arr.filter(
(connection) =>
!!connection.environmentId &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium persistence/mobile-storage.ts:157

loadSavedConnections accesses connection.environmentId on each array element without first checking that the element is an object. A persisted payload like {"connections":[null]} throws a TypeError while loading connections, instead of ignoring the malformed entry. Since saveConnection and clearSavedConnection both read the current list before writing, this crash also blocks any save or clear operation once a null element is present in storage. Add a guard (e.g. connection != null && typeof connection === "object") inside the Arr.filter predicate before dereferencing properties.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/persistence/mobile-storage.ts around line 157:

`loadSavedConnections` accesses `connection.environmentId` on each array element without first checking that the element is an object. A persisted payload like `{"connections":[null]}` throws a `TypeError` while loading connections, instead of ignoring the malformed entry. Since `saveConnection` and `clearSavedConnection` both read the current list before writing, this crash also blocks any save or clear operation once a `null` element is present in storage. Add a guard (e.g. `connection != null && typeof connection === "object"`) inside the `Arr.filter` predicate before dereferencing properties.

return "";
}
const record = options as Record<string, unknown>;
return stableJsonStringify({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium native/nativeStackOptionsSignature.ts:12

buildNativeStackOptionsSignature only hashes a fixed allowlist of fields, so changes to any other NativeStackScreenOptions property (e.g. headerShown changing from true to false) produce an identical signature. The caller then skips setOptions, leaving the screen with a stale navigation configuration. Include every forwarded option in the structural comparison, applying special handling only where needed.

Also found in 1 other location(s)

apps/mobile/src/native/StackHeader.tsx:114

lastSignatureRef skips navigation.setOptions based on a lossy signature: buildNativeStackOptionsSignature covers only a small subset of NativeStackNavigationOptions and replaces functions inside options such as headerSearchBarOptions with &#34;[fn]&#34;. If an omitted option changes, or a caller supplies a new search callback with otherwise identical options (as ThreadNavigationSidebar does), the signature remains unchanged and navigation retains the previous value/closure. This can leave screen configuration stale and dispatch search changes to an obsolete callback. Either include all option values with callback-safe wrappers, or limit this deduplication to the specific stabilized factories.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/native/nativeStackOptionsSignature.ts around line 12:

`buildNativeStackOptionsSignature` only hashes a fixed allowlist of fields, so changes to any other `NativeStackScreenOptions` property (e.g. `headerShown` changing from `true` to `false`) produce an identical signature. The caller then skips `setOptions`, leaving the screen with a stale navigation configuration. Include every forwarded option in the structural comparison, applying special handling only where needed.

Also found in 1 other location(s):
- apps/mobile/src/native/StackHeader.tsx:114 -- `lastSignatureRef` skips `navigation.setOptions` based on a lossy signature: `buildNativeStackOptionsSignature` covers only a small subset of `NativeStackNavigationOptions` and replaces functions inside options such as `headerSearchBarOptions` with `"[fn]"`. If an omitted option changes, or a caller supplies a new search callback with otherwise identical options (as `ThreadNavigationSidebar` does), the signature remains unchanged and navigation retains the previous value/closure. This can leave screen configuration stale and dispatch search changes to an obsolete callback. Either include all option values with callback-safe wrappers, or limit this deduplication to the specific stabilized factories.

Comment on lines +37 to +42
if (loaded) return
loaded = true
try {
regular = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Regular.ttf")
bold = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Bold.ttf")
} catch (error: RuntimeException) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium t3terminal/TerminalCanvasView.kt:37

If fonts/MesloLGS-NF-Bold.ttf is missing or invalid while the regular asset loads successfully, regular is updated to the Meslo face but bold stays at the default Typeface.MONOSPACE fallback. Because the terminal cell width is derived from the regular face, bold text then renders with mismatched glyph metrics, causing misaligned or overlapping cells. Assign to locals first and publish both only after both loads succeed, or derive bold from the loaded regular face so the metrics stay consistent.

    loaded = true
    try {
-      regular = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Regular.ttf")
-      bold = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Bold.ttf")
+      val regularFace = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Regular.ttf")
+      val boldFace = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Bold.ttf")
+      regular = regularFace
+      bold = boldFace
    } catch (error: RuntimeException) {
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt around lines 37-42:

If `fonts/MesloLGS-NF-Bold.ttf` is missing or invalid while the regular asset loads successfully, `regular` is updated to the Meslo face but `bold` stays at the default `Typeface.MONOSPACE` fallback. Because the terminal cell width is derived from the regular face, bold text then renders with mismatched glyph metrics, causing misaligned or overlapping cells. Assign to locals first and publish both only after both loads succeed, or derive `bold` from the loaded regular face so the metrics stay consistent.

@@ -296,6 +316,7 @@ function ConfiguredSettingsRouteScreen() {
return;
}

savePreferences({ liveActivitiesEnabled: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium settings/SettingsRouteScreen.tsx:319

savePreferences({ liveActivitiesEnabled: true }) on line 319 is called fire-and-forget after setLiveActivityUpdatesEnabled already committed the relay preference. If the local preference save fails, updateMobilePreferencesAtom rolls back its optimistic value — so the UI reverts to "disabled" while the relay stays enabled. The same divergence happens on the disable path (line 403): the relay is disabled but the local preference silently rolls back to enabled. In both cases the switch state and actual delivery behavior become inconsistent. Consider awaiting savePreferences and reverting the relay preference (or surfacing an error) when the local save fails.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 319:

`savePreferences({ liveActivitiesEnabled: true })` on line 319 is called fire-and-forget after `setLiveActivityUpdatesEnabled` already committed the relay preference. If the local preference save fails, `updateMobilePreferencesAtom` rolls back its optimistic value — so the UI reverts to "disabled" while the relay stays enabled. The same divergence happens on the disable path (line 403): the relay is disabled but the local preference silently rolls back to enabled. In both cases the switch state and actual delivery behavior become inconsistent. Consider awaiting `savePreferences` and reverting the relay preference (or surfacing an error) when the local save fails.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

The themeConfig setter calls parseThemeConfig(value) without first resetting cursorColorValue and paletteColors, so changing to a theme that omits cursor-color or palette entries leaves the previous theme's cursor color and palette in effect. Setting themeConfig to "" (or to a config that only changes a subset of colors) renders a mix of old and new theme colors instead of the requested theme. Consider resetting cursorColorValue and paletteColors to their defaults at the start of parseThemeConfig before applying the new config.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt around line 388:

The `themeConfig` setter calls `parseThemeConfig(value)` without first resetting `cursorColorValue` and `paletteColors`, so changing to a theme that omits `cursor-color` or `palette` entries leaves the previous theme's cursor color and palette in effect. Setting `themeConfig` to `""` (or to a config that only changes a subset of colors) renders a mix of old and new theme colors instead of the requested theme. Consider resetting `cursorColorValue` and `paletteColors` to their defaults at the start of `parseThemeConfig` before applying the new config.

mwolson added 4 commits July 14, 2026 09:27
Absorb Android keyboard scroll + keyboard-controller lockfile fixes from the shared ios-fixes-main branch.
Bring in the latest ios-fixes-main keyboard fixes via v2-base.
+ // "anchored to end" fact instead of relying on offset deltas alone.
+ anchorToEnd.value =
+ !inverted &&
+ scroll.value + layout.value.height - size.value.height >=

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium patches/react-native-keyboard-controller@1.21.13.patch:1115

anchorToEnd on open compares the scroll offset against blankSpace.value, but the true rest position is Math.max(blankSpace.value, padding.value + extraContentPadding.value). When extraContentPadding exceeds blankSpace, a user sitting above the real end of the scroll range is incorrectly flagged as anchored. The anchorToEnd branch then calls scrollTo with the live maximum scroll offset on every keyboard frame, jumping the conversation to the newest message and losing the user's reading position. Use the same Math.max(blankSpace.value, padding.value + extraContentPadding.value) calculation that the close path already uses when setting anchorToEnd on open.

🤖 Copy this AI Prompt to have your agent fix this:
In file @patches/react-native-keyboard-controller@1.21.13.patch around line 1115:

`anchorToEnd` on open compares the scroll offset against `blankSpace.value`, but the true rest position is `Math.max(blankSpace.value, padding.value + extraContentPadding.value)`. When `extraContentPadding` exceeds `blankSpace`, a user sitting above the real end of the scroll range is incorrectly flagged as anchored. The `anchorToEnd` branch then calls `scrollTo` with the live maximum scroll offset on every keyboard frame, jumping the conversation to the newest message and losing the user's reading position. Use the same `Math.max(blankSpace.value, padding.value + extraContentPadding.value)` calculation that the close path already uses when setting `anchorToEnd` on open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants